Skip to content

Conversation

@lihaoyi
Copy link
Contributor

@lihaoyi lihaoyi commented Oct 16, 2025

Since Thread.stop was removed (https://stackoverflow.com/questions/4426592/why-thread-stop-doesnt-work), the only way to re-implement such functionality is via bytecode instrumentation. This PR implements such functionality in the Scala REPL, such that we can now Ctrl-C to interrupt runaway code that doesn't check for Thread.isInterrupted() (which is what the current Thread.interrupt does) without needing to kill the entire JVM

These snippets are now interruptable where they weren't before:

scala> scala.collection.Iterator.continually(1).foreach(x => scala.Predef.identity(x))
^C
Interrupting running thread
java.lang.ThreadDeath
  at dotty.tools.repl.ReplCancel.stopCheck(ReplCancel.scala:22)
  at scala.collection.IterableOnceOps.foreach(IterableOnce.scala:619)
  at scala.collection.IterableOnceOps.foreach$(IterableOnce.scala:617)
  at scala.collection.AbstractIterator.foreach(Iterator.scala:1306)
  ... 32 elided
                                                                                                                          
scala> 
                                                                                                                          
scala> var x = 1; while(true) x += 1
^C
Interrupting running thread
java.lang.ThreadDeath
  at dotty.tools.repl.ReplCancel.stopCheck(ReplCancel.scala:22)
  ... 32 elided
                                                                                                                          
scala>           

scala> def fib(n: Int): Int = if (n <= 0) 1 else fib(n-1) + fib(n-2); fib(99)
^C
Interrupting running thread
java.lang.ThreadDeath
  at dotty.tools.repl.ReplCancel.throwIfReplStopped(ReplCancel.scala:16)
  at rs$line$2$.fib(rs$line$2)
  at rs$line$2$.fib(rs$line$2:1)
  at rs$line$2$.fib(rs$line$2:1)
...
                                                                                                                          
scala> 

The way this works is that we instrument all bytecode that gets loaded into the REPL classloader using scala.tools.asm and add checks at every backwards branch and start of each method body. These checks call a classloader-scoped ReplCancel.stopCheck method, and the Ctrl-C handler is wired up to flip a var and make the stopCheck() calls fail.

This adds some incremental performance hit to code running in the Scala REPL, but the result of no longer needing to trash your entire REPL process and session history due to a single runaway command is probably worth it. There may be other ways to instrument the code to minimize the performance hit. Some rough benchmarks:

var start = System.nanoTime(); var x = 1L; while(true) { x += 1; if (x % 100000000 == 0){ val next = System.nanoTime(); println(next - start); start = next}}
  • if (boolean) throw (the current implementation): ~2ns per loop
  • 1/int, which throws when int == 0: ~2ns per loop
  • if (Thread.interrupted()): ~2ns per loop
  • No instrumentation: ~1ns per loop

An exponential-but-technically-not-infinite recursion benchmark below shows a minor slowdown from the start-of-method-body instrumentation (~6%):

def fib(n: Int): Int = if (n <= 0) 1 else fib(n-1) + fib(n-2); val now = System.nanoTime(); fib(40); val duration = System.nanoTime() - now
  • With instrumentation: 753,994,875ns
  • No instrumentation: 712,178,417ns

This 50% slowdown is the worst case slowdown that instrumentation adds; anything more complex than a while(true) x += 1 loop will have a longer time taken, and the % slowdown from instrumentation would be smaller. Probably can expect a 10-20% slowdown on more typical code

This instrumentation is on by default on the assumption that most REPL work isn't performance sensitive, but I added a flag to switch it off and fall back to the prior un-instrumented behavior which would require terminating the process to stop runaway code.

One consequence of this is that REPL-loaded classes will be different from non-REPL-loaded classes, due to the bytecode instrumentation and class re-definition. So use cases embedding the REPL into an existing program to interact with it "live" would need to pass -Xrepl-disable-bytecode-instrumentation to allow classes and instances to be shared between them

The jshell REPL also allows interruption of these snippets, and likely uses a similar approach though I haven't checked

@lihaoyi lihaoyi changed the title Re-implement Ctrl-C interruption for Scala REPL via bytecode instrumentation Re-implement Ammonite's Ctrl-C interruption for Scala REPL via bytecode instrumentation Oct 17, 2025
@lihaoyi lihaoyi force-pushed the repl-interrupt branch 2 times, most recently from 8e6c29f to ece60fa Compare October 17, 2025 06:48
@Gedochao Gedochao requested review from bracevac and tgodzik October 17, 2025 07:34
@lihaoyi lihaoyi force-pushed the repl-interrupt branch 3 times, most recently from a68d5b1 to 7fdf5c6 Compare October 17, 2025 15:35
@lihaoyi
Copy link
Contributor Author

lihaoyi commented Oct 21, 2025

Might need some help with the failing tests. @bracevac do you have any idea? Maybe some classloader setup is different in those tests?

@Gedochao Gedochao requested a review from tanishiking October 23, 2025 07:08
@bracevac
Copy link
Contributor

The failing community build test has been fixed by now and should pass after a rebase.
As for the others, I'm not too familiar with those areas and need to dig.

@lihaoyi
Copy link
Contributor Author

lihaoyi commented Oct 24, 2025

One option is to simply pass -Xrepl-disable-bytecode-instrumentation to the test jobs that are failing. That would also ensure we have some decent coverage for usage of that flag, otherwise the entire CI is run without the flag passed so we have no idea if it works at all of it would explode if used

@lihaoyi
Copy link
Contributor Author

lihaoyi commented Oct 25, 2025

Got the tests green. The problem in that case was we were accidentslly instrumenting the clasloaders used in non-repl contexts as well. That has since been fixed

@lihaoyi
Copy link
Contributor Author

lihaoyi commented Oct 25, 2025

One consequence of this is that REPL-loaded classes will be different from non-REPL-loaded classes, due to the bytecode instrumentation and class re-definition. So use cases embedding the REPL into an existing program to interact with it "live" would need to pass -Xrepl-disable-bytecode-instrumentation to allow classes and instances to be shared between them

@bracevac
Copy link
Contributor

@lihaoyi could you clarify how you produced a REPL binary that works? Trying it with bin/scala in the compiler source folder on your branch does not seem to properly terminate the computation on ^C:

scala> def fix(x: Int): Int = fix(x)
1 warning found
-- Warning: --------------------------------------------------------------------
1 |def fix(x: Int): Int = fix(x)
  |^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |Infinite recursive call
def fix(x: Int): Int

scala> fix(0)
^C
Interrupting running thread

Nothing happens at this point and a second ^C will just terminate the REPL session.

@lihaoyi
Copy link
Contributor Author

lihaoyi commented Oct 27, 2025

@bracevac sorry should work now, last PR missed on spot in Rendering.scala where we needed to thread the flag b413546

"-color:never",
"-Xrepl-disable-display"
"-Xrepl-disable-display",
"-Xrepl-disable-bytecode-instrumentation"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing this option will indeed disable ^C interruption, but it will still show Interrupting running thread. Perhaps suppress the message fully?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Interrupting running thread is actually meant to indicate that Thread.interrupt is being called. The current logic does both by default, and only Thread.interrupt if instrumentation is disabled. Maybe we can change the message if it's confusing?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, maybe a different message indicating that the option is active and it won't work. As it is, I find it confusing.

Co-authored-by: Oliver Bračevac <bracevac@users.noreply.github.com>
lihaoyi and others added 2 commits October 27, 2025 15:59
Co-authored-by: Oliver Bračevac <bracevac@users.noreply.github.com>
Co-authored-by: Oliver Bračevac <bracevac@users.noreply.github.com>
@lihaoyi
Copy link
Contributor Author

lihaoyi commented Oct 27, 2025

@bracevac the formatting is whatever the intellij default is. If there's an autoformatter I'll run it, but if there isn't an autoformatter I generally don't spend too much time fiddling with whitespace and just go with the defaults

Co-authored-by: Oliver Bračevac <bracevac@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants